feat(lint): translation reference integrity + option-key validation (#3583) - #3806
Merged
Conversation
…3583) The i18n gate only ever ran forward: `os i18n check` asks which keys the metadata expects that no bundle carries. Nothing asked the reverse — which keys a bundle carries that no metadata claims — though the spec already names the answer (`TranslationDiffStatus 'redundant'`, `redundantKeys`), both declared with no producer. That direction ships two failure modes, both in the HotCRM audit: bundles keyed to fields an object no longer declares, and option translations keyed by the display label or a variant spelling of the stored value (`direct-mail` for `direct_mail`, `planned` for `planning`). Neither breaks anything — the resolver finds nothing and renders the source string, so the screen looks translated while one field quietly is not. `validateTranslationReferences` walks every bundle in `stack.translations` against the stack it ships with — objects, fields, option values, views, actions and their params, sections, apps and nav ids, dashboards, widgets, header actions, global actions — wired into `validate`, `lint` and `compile`. All findings are warnings: an orphan key is inert, not broken. Cross-package objects keep the S3 rule: a registered platform object is skipped wholly (its fields are invisible to a stack lint), an unregistered platform-prefixed name is reported once on the object key, and no subtree is half-checked. `messages`, `validationMessages`, `settings`, `settingsCommon` and `metadataForms` are deliberately unjudged — their keys belong to code, plugins and the platform registry, so there is no universe to resolve against. Two example apps carried the exact bug class and are fixed at the producer: app-crm translated `account_id`/`contact_id`/`opportunity_id` (fields are `account`/`contact`/`opportunity`), `first_name`/`last_name` on a lead that declares `name`, two sections nothing declares, and app `crm` (it is `crm_app`); app-todo keyed every bundle to `task` while the object is `todo_task` — 92 strings that looked translated were unreachable, which the coverage ratchet now records. Its completeness test hard-coded the same wrong key, so it stayed green against a bundle no resolver could find; it now reads the key from `Task.name`. Verified: lint 492 / cli 673 tests pass; the rule reports zero findings across app-crm, app-showcase and app-todo after the fixes, having visited 274 field keys, 94 option keys and 5 app keys over 35 object nodes — a floor with substance, not an empty walk. Refs #3583 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…alidation-midr20 # Conflicts: # scripts/i18n-coverage-baseline.json
os-zhuang
marked this pull request as ready for review
July 28, 2026 06:46
…st it (#3583) Running the new rule over HotCRM (~18k lines of shipped metadata) reported ~40 `_views` keys as orphans. Every one of them was correct; the rule was wrong, in two compounding ways: 1. A view record is a CONTAINER, not a view. The default list lives at `list` and the named tabs at `listViews.<key>` / `formViews.<key>`, each of which may also carry its own `name`. Collecting only the record's own `name` makes every named tab look undeclared. 2. The object binding lives INSIDE the container (`list.data.object`), not at the record root. The record-level lookup resolved to nothing on the canonical shape, so the whole record was skipped — which is why the universe was empty enough to report the app's entire view surface. The examples missed this because none of their bundles carried `_views` keys at all: the walk was exercised, but that one branch of the universe never was. A corpus with the keys present is what caught it — §6.1's point about a false-positive floor only meaning something when the walk is non-empty applies per branch, not per rule. `collectViewRecord` now registers each container under the object THAT container binds (falling back to the record, then to the default list), and accepts both the map key and the inner `name` — authors write either, and the key is what the console renders the tab from. Form sections are collected in the same pass, so `_sections` gets the same binding fix. Objects carrying views directly (`objects[].views`, `objects[].listViews`) go through one code path with the owner pinned. Three regression tests pin the HotCRM shape. Residual on HotCRM after the fix: 6 findings, all true — `apps.crm_enterprise.navigation.group_products` and `.group_analytics` across three locales, where the app declares group_sales / group_work / group_marketing / group_service / group_insights and neither translated id exists. Refs #3583 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS
This was referenced Jul 28, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 28, 2026
…DR-0109 draft (#3820) (#3871) * feat(lint,spec): AI surface-affinity rule, ADR-0063 doc completion, ADR-0109 draft (#3820) Positioning work ahead of R7 (issue #3820): the agent authoring surface is closed per ADR-0063, so the buildable AI rule is the one that checks a statically provable, runtime-fatal contradiction — skill ↔ agent surface affinity (ADR-0064 §3) — not agent.skills resolution. - lint: new `validate-ai-surface-affinity` rule + tests, appended to REFERENCE_INTEGRITY_RULES so `validate`/`lint`/`compile` all pick it up. Error severity: the runtime throws on this binding at chat time. Unresolved skill names are deliberately out of scope (kernel skills are runtime-registered; #3820 D0/D2). False-positive floor read per branch (#3806): clean on the HotCRM-shaped corpus, `examples/` proves nothing here. - spec: stack.zod.ts AI-slot prose no longer describes the withdrawn ADR-0040 model — agents marked platform-internal (ADR-0063 §2), tools marked declaration-only pending #3820 D0, skills named as THE extension primitive. app.defaultAgent re-documented as a surface-binding knob (its example was the withdrawn tenant-agent pattern). SkillSchema documents that per-skill `permissions` deliberately does not exist (ADR-0049) — the field HotCRM authored and Zod silently stripped. - docs: ADR-0109 (Proposed) — the #3820 D0 decision: third-party tools are bindings to actions/flows; platform tool names become a conformance-tested registry; unblocks the R7 skill.tools branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4 * chore: changeset + regenerate app reference for defaultAgent doc (#3820) Check Changeset and the generated-docs gate in TypeScript Type Check both flagged the previous commit: add the missing changeset (@objectstack/lint minor, @objectstack/spec patch) and the regenerated content/docs/references/ui/app.mdx row for the re-documented `app.defaultAgent`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4 --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#3583 方案的 Phase 3 / R6 —— 标题里 option-key validation 的那一半。同 PR 接入
validate/lint/compile。问题:i18n 门禁只有一个方向
os i18n check问的是「元数据期望、但 bundle 里没有的键」。反方向从来没人问过 —— bundle 里有、但元数据不认的键 —— 尽管 spec 早就把答案命名好了:TranslationDiffStatus 'redundant'与TranslationCoverageResult.redundantKeys声明了却没有任何生产者。HotCRM 审计里这个方向发布了两类问题:
direct-mail之于direct_mail、planned之于planning)。两类都不会崩 —— 这正是麻烦所在。解析器查不到就回落到源串,于是整屏看着都翻译好了,只有那一个字段/一个选项静悄悄没翻。
validate-translation-references遍历
stack.translations的每个 bundle,对照它随行的 stack:objects.{object}objects.{object}.fields.{field}objects.{object}.fields.{field}.options.{key}value._views/._actions/._sections/._actions.*.paramsname/ 绑定到该对象的动作 /fieldGroups[].key或具名 section / 参数nameapps.{app}/.navigation.{id}name/ 导航项iddashboards.{dash}/.widgets.{id}/.actions.{actionUrl}name/ widgetid/ 头部actionUrlglobalActions.{action}objectName的动作全部为 warning(
translation-target-unknown/translation-option-key-unknown)。孤儿键是「惰性」而非「损坏」,严重级应当如实说出这一点 —— 这比 R1/R2 的死引用(error)弱一档。诊断里带上可选的已声明名字;当键其实是显示 label 时直接点名对应的 value;并加了一个命名空间段建议通道:task→todo_task(编辑距离 5,纯 Levenshtein 给不出,而这恰恰是最该给建议的一类)。跨包对象沿用 §4 阶梯且保留 S3:注册表里的平台对象整棵跳过(stack 侧看不见它的字段,就不该评判),带平台前缀但没人注册的名字只在对象键上报一次,绝不半查子树。
刻意不判:
messages、validationMessages、settings、settingsCommon、metadataForms—— 它们的键属于应用代码、插件和平台自己的元数据类型注册表,不存在可枚举的合法集合,写规则就是在猜。同理,对象优先的AppTranslationBundle(o.*)是translation元数据类型、不是stack.translations,其键被跳过而不是报错。顺带修掉的真实实例(在示例应用里)
零误报的前提是先把真发现修在生产者侧(Prime Directive #12):
account_id/contact_id/opportunity_id(字段其实叫account/contact/opportunity)、lead 上的first_name/last_name(对象只有name)、两个谁都没声明的_sections、以及应用键crm(应用是crm_app,连带sales/admin两个导航键也都不存在)。task,而对象是todo_task—— 92 个看起来已翻译的字符串根本不可达,i18n 覆盖率棘轮把这一点记了下来(212 → 120 未翻译,基线已更新)。它的
translation-completeness.test.ts把同一个错键写死成字面量,于是对着一个解析器永远找不到的 bundle 常绿 —— 一个和 bundle 达成一致、而不是和元数据达成一致的完备性测试。现改为从Task.name读取对象键。验证
eslint、check:nul-bytes、check:i18n、check:i18n-coverage、check:doc-authoring干净;lint 包 build(含 DTS)通过。文档
content/docs/protocol/kernel/i18n-standard.mdx新增「Orphan Keys and Option Keys」小节:写明被检查的键面、警告级别、以及刻意不检查的命名空间及其原因(ADR-0049 的标注纪律)。skills/objectstack-i18n/SKILL.md:选项键按 value 不按 label,并说明这条现在会被 lint 出来。剩余(不在本 PR)
R7
validate-ai-references(第 6 类,knowledge.indexes半边仍卡在决策 D1)、R8(Tier-B 候选,需先出验证说明)。因此本 PR 用 Refs 而非 Closes 关联 #3583。Refs #3583 · #3640(方案)· #3657(Phase 1)· #3684(Phase 2)· #3698(R5)· ADR-0072 / ADR-0078 / ADR-0085
🤖 Generated with Claude Code
https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS
Generated by Claude Code